CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary#85
Conversation
…xtension
Implement the optional InstallExternalLibrary and UninstallExternalLibrary APIs
for the .NET C# language extension, enabling CREATE/DROP EXTERNAL LIBRARY support
for both ZIP-packaged and raw DLL C# libraries in SQL Server.
Implementation:
- Native C++ entry points in nativecsharpextension.cpp with null-runtime guards
- Managed C# implementation in CSharpExtension.cs using System.IO.Compression.ZipFile
- ZIP magic byte detection (PK header) to distinguish ZIP vs raw DLL content
- ZIP path: extract to temp dir, scan for inner .zip and extract to install dir;
if no inner ZIP, copy files directly. Creates {libName}.dll alias when extracted
file names differ from the SQL library name, so DllUtils can discover them.
- Raw DLL path: copy file to install dir as {libName}.dll
- UninstallExternalLibrary clears all files/dirs from the install directory
- Unique temp folder per call (Guid-based) to prevent race conditions
- Fix DllUtils.CreateDllList to search userLibName + ".*" pattern instead of
exact name (pre-existing bug: exact name never matched files with extensions)
Testing:
- 14 new test cases in CSharpLibraryTests.cpp
- Updated ExecuteInvalidLibraryNameScriptTest for DllUtils pattern change
- 9 test packages in test/test_packages/
- All 73 tests pass (59 existing + 14 new)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e81fdfb to
5377b74
Compare
5377b74 to
82f9cbc
Compare
…fest-based cleanup, conflict detection, ALTER support - Fix build script to use VS 2022 instead of VS 2019 - Add global.json to pin .NET SDK 8.0 - Fix: use library name as-is (no unconditional .dll append) - InstallExternalLibrary: ZIP extraction with file conflict detection, manifest tracking - UninstallExternalLibrary: manifest-based targeted file deletion, bottom-up empty dir cleanup - ALTER EXTERNAL LIBRARY: clean up previous install before re-installing - CHANGES.md documenting all changes
82f9cbc to
10ce19a
Compare
…LTER New tests: - ManifestWrittenTest, ManifestListsNestedFilesTest - InstallLibNameAliasNoExtensionTest - DirectoryOverlapAllowedTest, FileConflictFailsTest - UninstallPreservesOtherLibrariesTest - UninstallRemovesEmptyNestedDirsTest - AlterExternalLibraryTest Update 3 existing tests to reflect new libName-without-extension naming: - InstallInvalidZipTest, InstallRawDllNotZipTest, InstallZipWithManyFilesTest
- ErrorMessagePopulatedOnFailureTest: verify libError is surfaced to SQL Server for non-existent file, zip-slip, and file-conflict failure modes - UninstallNonZipLibraryTest: raw-DLL install/uninstall (no-manifest path) - InnerZipFileConflictFailsTest: conflict detection in inner-zip code path - TempFolderCleanedUpAfterConflictTest: no GUID temp-dir leaks after failures - AlterFromNonZipToZipTest: ALTER from raw DLL to ZIP (missing-manifest case) - AliasFileRemovedOnUninstallTest: libName-alias file recorded in manifest and removed on uninstall
…validation, Linux case-sensitivity, cleanup
- Alias file now written as {libName}.dll so DllUtils.CreateDllList ({libName}.*) finds it (fixes feature regression on Linux)
- Raw-DLL install writes {libName}.dll; uninstall fallback updated to match
- Defense-in-depth zip-slip check on inner-ZIP entries before adding to manifest and again in CleanupManifest before deleting
- ALTER is now transactional: stage + validate new ZIP before removing old version, with manifest-aware conflict suppression
- Reject library names containing path separators, '..', null, or absolute paths
- Use OS-appropriate path comparer (Ordinal on Linux, OrdinalIgnoreCase on Windows)
- Sort dirs for bottom-up cleanup by separator count, not string length
- Extract conflict detection into single helper (removes duplicate check)
- Trim error messages and comments; update 3 tests to match new alias naming
This PR branch is based on sicongliu's branch which predates PR microsoft#83 (DECIMAL Type Support on main). PR microsoft#83 added Microsoft.Data.SqlClient 5.2.2 as a required runtime dependency for SqlDecimal handling. Without it, sp_execute_external_script fails with HRESULT 0x80004004 when any script touches DECIMAL types.
…nstall-external-library
When CREATE EXTERNAL LIBRARY is called with a name ending in .dll (e.g. [Scriptoria.dll] WITH (LANGUAGE='dotnet')), the raw-DLL install path and missing-alias fallback both unconditionally appended '.dll', producing files like 'Scriptoria.dll.dll' on disk. The CLR assembly resolver could not locate the assembly by simple name and ExtHost died with 'Could not load file or assembly Scriptoria'. Added DllFileNameFor(libName) helper that returns libName as-is if it already ends in .dll (case-insensitive on Windows via existing s_pathComparison), otherwise appends .dll. Applied at the three sites in CSharpExtension.cs: raw-DLL install path, missing-alias fallback inside the ZIP install path, and UninstallExternalLibrary raw-DLL cleanup. ZIP-based installs whose library names do not end in .dll are unaffected. Verified end-to-end on consumer side: Test-SpAiTaskSkills.ps1 reports 4 passed, 0 failed.
…-install-external-library
InstallRawDllWithDllSuffixedLibNameTest covers the case where the library is created via CREATE EXTERNAL LIBRARY [foo.dll] (libName already ending in .dll). Asserts that the raw DLL is written as 'foo.dll' (not 'foo.dll.dll') and that uninstall removes it from the same single-.dll path. Pairs with the DllFileNameFor helper added in this PR.
There was a problem hiding this comment.
Pull request overview
Adds ZIP-archive support (including nested file trees) for the .NET Core C# language extension’s InstallExternalLibrary / UninstallExternalLibrary, aiming to make installs idempotent and uninstalls precise via a per-library manifest.
Changes:
- Implemented managed
InstallExternalLibrary/UninstallExternalLibrarywith manifest-driven uninstall, conflict detection, and temp-folder staging. - Updated DLL discovery logic in
DllUtilsand added native exports/wiring for the new APIs. - Added extensive native tests plus new ZIP/DLL test packages for edge cases (zip-slip, empty zips, nested trees, many files, etc.).
Reviewed changes
Copilot reviewed 9 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs | Implements managed install/uninstall logic (ZIP extraction, manifests, conflict checks, alias handling). |
| language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs | Adjusts library DLL discovery pattern matching. |
| language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp | Exposes native InstallExternalLibrary / UninstallExternalLibrary forwarding into managed code. |
| language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h | Declares the new native library-management API exports. |
| language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h | Adds function pointer typedefs and fixture members for install/uninstall APIs. |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp | Loads the install/uninstall exports for tests. |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp | Adds comprehensive unit tests for the new behaviors (manifest uninstall, conflicts, aliasing, ALTER-like reinstall, zip-slip, etc.). |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp | Tweaks invalid-library-name test input. |
| language-extensions/dotnet-core-CSharp/test/test_packages/*.zip / *.dll | Adds new fixture packages (nested layout, many files, zip-slip, bad zip, raw dll, etc.). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…e validation, recursive/conflict-checked alias, CopyDirectory no-overwrite
DllUtils.CreateDllList: try exact userLibName first; fall back to userLibName + '.*' only if no exact match. Extracted into AddMatches helper. Fixes callers that pass an explicit filename like 'Foo.dll' which previously became 'Foo.dll.*' and matched nothing.
CSharpExtension.UninstallExternalLibrary: call ValidateLibraryName before building manifestPath / libraryFile. Prevents malicious or legacy names with path separators from resolving outside installDir.
CSharpExtension.InstallExternalLibrary alias creation: (a) search the full extracted tree (not just top-level) for an existing '{libName}.*' before deciding to create an alias; (b) include the alias path in the conflict-check input so a collision fails BEFORE any content is written to installDir. Prevents partial-state failures when another library already owns '{libName}.dll' at the root.
CSharpExtension.CopyDirectory: use File.Copy overwrite:false (was overwrite:true) so TOCTOU changes between conflict-check and write fail loud rather than silently clobbering another library's files.
Tests: added UninstallRejectsPathTraversalLibNameTest and AliasConflictDetectedBeforeExtractionTest to cover the new behaviors.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 17 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The test asserts exactly 50 Since the test passes, one of these must be true:
Whichever it is, the test description ("Tests that installing a zip containing many files (50 DLLs) extracts all of them correctly") doesn't explain the relationship between the package contents, the alias, and the expected count. A future maintainer changing the test package or the alias logic could break this test without understanding why. Please add a comment clarifying what the ZIP actually contains and how the count of 50 accounts for (or excludes) the alias. For example: // The ZIP contains 49 DLL files + 1 .txt file. Since none matches
// "manyfilespackage.*", an alias "manyfilespackage.dll" is created,
// bringing the total DLL count to 50.(or whatever the actual breakdown is) |
|
The name suggests this tests handling of a corrupt/invalid ZIP file. But A truly invalid ZIP test would need a file that starts with This test is really verifying: "a file without ZIP magic bytes falls through to the raw-DLL install path regardless of its |
…dirs ZIP guard, reparse-point sweep, libName extension-only rejection, refactor InstallExternalLibrary into helpers, doc + style sweep, 7 new tests
Block 1 substantive fixes:
- C1: AcquireInstallLock serializes Install + Uninstall on the same installDir via {installDir}/.install.lock (FileShare.None, DeleteOnClose, 100ms retry, blocks forever)
- C3: ValidateLibraryName rejects extension-only names (.dll, .txt, etc.) via Path.GetFileNameWithoutExtension
- E1: extractedFiles.Count==0 guard after collection prevents silent ALTER data loss when ZIP contains only directory entries
- E2: SAFETY comment on inner-zip ExtractToDirectory documenting why direct-to-installDir is safe today + tripwire for future .NET versions
- E3: New IsReparsePoint helper; root-level loops in Install + CollectRelativeFiles + CopyDirectory all guarded
Block 2 test fixes:
- E4: 5 new tests (AlterFromNonZipToNonZipTest, InstallRejectsInvalidLibNameTest, InstallZipWithDllSuffixedLibNameTest, UninstallWithMissingInstallDirTest, UninstallPreservesSharedNestedDirsTest) + new fixture testpackageJ-NESTED2.zip
- E5/E6/E7/E8/E10/E11: stronger assertions, removed redundant loops, exact equality, byte-for-byte content checks
- E12: surfaced + fixed latent test bug (dllCount expected 50 but real value with alias is 51)
- E13: InstallInvalidZipTest renamed to InstallNonZipFileAsRawDllTest
Block 3 refactor + style + docs:
- C6: InstallExternalLibrary split into orchestrator + 6 helpers (InstallRawDll, InstallZipPackage, FindInnerZip, CollectStagedFiles, DetermineAliasSource, ExtractContentToInstallDir, CreateAlias)
- A1-A8: empty lines before comments, single return, no var, brace style, scoped blocks unwrapped
- A5: s_pathComparer/s_pathComparison/s_lockRetryDelayMs moved to top class members
- B1-B5: full XML doc comments with <param> blocks; expanded IsZipFile + ValidateRelativePath comments
- C4: Install summary clarifies ZIP is allowed (not expected); raw DLL still supported
- C5: Logging.Trace -> Logging.Error in CleanupManifest skip path
- D1: <experimental/filesystem> -> <filesystem>
- D2: DirectoryHasFiles -> DoesDirectoryHaveFiles
- D4: Logging.Trace after manifest write (both raw-DLL and ZIP paths)
Responses to PR conversation comments (commit 38c553d)Combined response to the 13 PR-conversation comments from the 2026-04-24 review. (Inline review comments have been replied to individually as threaded replies.) #issuecomment-4315981621 — Empty-directories ZIP → silent data loss on ALTERExcellent catch — fixed. The new guard fires after if (extractedFiles.Count == 0)
{
throw new InvalidOperationException(
"The library archive contains no files.");
}New regression test #issuecomment-4316015154 — Inner-zip path bypasses CopyDirectory (reparse-point defense)Took option β (the SAFETY comment). Routing through tempFolder + // SAFETY: Extracting directly to the live installDir is currently safe
// because ZipFile.ExtractToDirectory does not restore symlink entries as
// actual symlinks on any platform -- they are written as regular files
// containing the link target text...
//
// If a future .NET version changes this (e.g. honors symlink entries on
// Linux), or if we switch to a different extraction library, this path
// MUST be re-routed through a separate temp folder followed by an
// IsReparsePoint-guarded copy -- mirror the non-inner-zip branch below
// for the pattern.Happy to do option α (route through tempFolder) if you'd prefer the more-defensive form. #issuecomment-4316028053 — Root-level files skip the reparse-point checkExcellent catch — fixed. Extracted the per-entry check into a single
The helper's #issuecomment-4316048256 — Test coverage gaps (6 sub-items)Mostly addressed:
5 new TEST_F + 1 new fixture; sub-item (3) deferred per the test-seam reasoning above. #issuecomment-4316334407 —
|
…111/111)
Found and fixed 4 issues introduced by the v3.9.0 changeset:
1. Lock file in installDir interfered with tests enumerating installDir contents and racing fs::remove_all on DeleteOnClose. Moved lock to a sibling path '{installDir}.install.lock' (concatenated, NOT a child of installDir).
2. C6 refactor lost a temp-folder cleanup invariant: 'tempFolder = InstallZipPackage(...)' only published the path on successful return, so a throw inside InstallZipPackage left an unreachable tempFolder leaking inside installDir. Converted to 'out string tempFolder' assigned BEFORE any work that can throw.
3. Three test bodies read files via std::ifstream without explicit close(); on Windows this raced CleanupInstallDir's fs::remove_all because the destructor runs lexically late. Added explicit .close() calls (RawDllInstallFailsIfForeignFileExists, InstallLibNameAlias, AlterFromNonZipToZip).
4. ErrorMessagePopulatedOnFailureTest mode 2 was checking for our ValidateRelativePath exception text, but .NET's ZipFile.ExtractToDirectory built-in zip-slip guard fires first with its own message ('outside the specified destination directory'). Updated assertion to match what actually surfaces.
Also: CMakeLists.txt was passing '--std=c++17' (a GCC/Clang flag, silently ignored by MSVC), so the test suite was actually building at MSVC's default C++14 the whole time. Switched to a generator expression that emits '/std:c++17' for MSVC and '--std=c++17' for non-MSVC compilers. Required to make D1 (std::filesystem) work.
Local test run: 111 passed, 0 failed (was 75 passed / 36 failed pre-fix).
Pin System.Text.Json to 10.0.4 explicitly in both csprojs so the extension's published output ships the 10.0.4 assembly (overriding the in-box net8 STJ 8.x in the extension's own AssemblyLoadContext). * Microsoft.SqlServer.CSharpExtension.csproj * Microsoft.SqlServer.CSharpExtensionTest.csproj NuGet automatically bumped two companion packages to match: * System.IO.Pipelines -> 10.0.4 * System.Text.Encodings.Web -> 10.0.4 No other transitive bumps were needed. TFM stays net8.0. Also fix stale comment in nativecsharpextension.cpp: "expected to be a zip" -> "may be a ZIP archive or a raw DLL" to match the header and managed code. Verified locally: * dotnet build (production csproj): 0 warnings, 0 errors. * dotnet build (test csproj): 0 warnings, 0 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The STJ 10.0.4 pin causes a runtime version mismatch: the extension
loads into the default AssemblyLoadContext via hostfxr, where the
shared framework (Microsoft.NETCore.App 8.0.x) already provides
STJ 8.0.0.0. A local STJ 10.0.0.0 cannot coexist in the same ALC.
Revert the PackageReference additions from both csprojs. Retain the
stale-comment fix in nativecsharpextension.cpp ("expected to be a
zip" -> "may be a ZIP archive or a raw DLL").
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
98caa4b to
a0d7aae
Compare
JustinMDotNet
left a comment
There was a problem hiding this comment.
Code Review Summary
Overall: Solid implementation with excellent test coverage (37+ tests). The Install/Uninstall APIs handle ZIP packages, raw DLLs, ALTER permutations, conflict detection, alias lifecycle, and concurrent locking well. A few items worth addressing before merge.
Findings: 1 bug (pre-existing), 4 warnings, 3 suggestions. See inline comments.
yaelh
left a comment
There was a problem hiding this comment.
Most comments have been addressed — nice work on the refactor and test additions. Requesting changes for the remaining items:
- Inner-zip extraction path: please take option (α) — route through tempFolder + CopyDirectory rather than relying on the SAFETY comment alone.
- Reparse-point regression test: add a test that validates the symlink guard fires, even if it's a no-op on Windows today.
- IsReparsePoint remarks: make the threat model concrete with an attack scenario example (sneaky.dll → /etc/shadow).
- DirectoryOverlapAllowedTest: rename to reflect what it actually tests.
- InstallZipWithManyFilesTest: clarify whether this test was actually being run before the fix — EXPECT_EQ(dllCount, 50) with 51 DLLs on disk should have failed.
| // extension and the resulting "{libName}.manifest" / "{libName}.dll" | ||
| // paths would be hidden dotfiles on Linux and opaque on both | ||
| // platforms. | ||
| if (string.IsNullOrEmpty(Path.GetFileNameWithoutExtension(libName))) |
There was a problem hiding this comment.
does adding IsNullOrWhitespace make sense here? I tried creating a file on windows like .txt, but it was automatically modified .txt, so that's fine. but I don't know what linux does
There was a problem hiding this comment.
Done in 55d15e3. Added string.IsNullOrWhiteSpace(libName) as the first check in ValidateLibraryName. Cheap and defensive — Linux filesystems will accept " " as a directory name, and even on Windows a NUL-delimited string with leading/trailing whitespace can sneak past Win32's filename parser depending on the API. New " " row added to InstallRejectsInvalidLibNameTest.
There was a problem hiding this comment.
Verified — ValidateLibraryName first check at CSharpExtension.cs:1574-1577 is string.IsNullOrWhiteSpace(libName). New " " row added to InstallRejectsInvalidLibNameTest per the test on line 1779. Cheap and defensive — agree.
There was a problem hiding this comment.
I meant adding IsNullOrWhiteSpace in addition to IsNullOrEmpty, not instead of it. aren't both empty and whitespace invalid?
There was a problem hiding this comment.
Good follow-up. string.IsNullOrWhiteSpace is a strict superset of string.IsNullOrEmpty -- per the .NET docs:
Returns true if the value parameter is
nullorString.Empty, or if value consists exclusively of white-space characters.
So the single IsNullOrWhiteSpace check at the top of ValidateLibraryName already rejects:
null""(empty)" "/"\t"/"\n"(whitespace-only)
There is no input that IsNullOrEmpty would reject that IsNullOrWhiteSpace accepts -- the latter implements the former plus the whitespace case. Adding both would be redundant: IsNullOrEmpty(s) || IsNullOrWhiteSpace(s) is exactly equivalent to IsNullOrWhiteSpace(s) for every possible s.
Coverage is in InstallRejectsInvalidLibNameTest: rows for "" and " " both reach the same ArgumentException ("Library name must not be empty or whitespace.") via the single IsNullOrWhiteSpace check.
Happy to add the explicit IsNullOrEmpty call too if you'd prefer it for readability -- just say the word and I'll push a one-liner. But on behavior alone, the current code already covers everything you described.
…, A2-A7 correctness/robustness, B1 symlink regression test, doc + test cleanups
Bug / correctness fixes (Justin + yaelh review):
A1 nativecsharpextension.cpp -- replace `new std::string(...)` +
`c_str()` with `malloc(len+1)` + `memcpy` so the returned pointer
owns its own buffer. Matches the managed `Marshal.AllocHGlobal`
contract that ExtHost expects on the other side. Fixes UB where
ExtHost would dereference a pointer into a freed `std::string`
internal buffer.
A2 CSharpExtension.cs -- narrow the `IOException` catch in
`EnsureDir` via `when (IsSharingViolation(ex))` filter that
inspects HResult for ERROR_SHARING_VIOLATION (32) /
ERROR_LOCK_VIOLATION (33). Other IOException variants
(DirectoryNotFoundException, PathTooLongException, mid-creation
failures) and non-IOException exceptions (UnauthorizedAccess,
Argument, Security) now propagate fast instead of being swallowed.
A3 DllUtils.cs -- replace
`Directory.GetFiles(searchPath, "{name}.*")` +
`.Where(...EndsWith(".dll"))` with
`Directory.EnumerateFiles(searchPath)` + explicit
`Equals(".dll", OrdinalIgnoreCase)` on extension and
`Equals(userLibName, OrdinalIgnoreCase)` on stem. No more reliance
on Win32 `FindFirstFile` wildcard semantics; eliminates the
"*.dll matches foo.dllx" and "Foo.* matches short-name 8.3 alias"
over-match quirks. Remarks block documents both quirks for the
next reader.
A4 + B1 CSharpExtension.cs + CSharpLibraryTests.cpp +
test_packages/testpackageK-SYMLINK.zip +
test_packages/build-symlink-fixture.ps1 -- collapse the
inner-zip and outer-zip install paths through a single
`contentRoot` + `IsReparsePoint`-guarded
`ExtractContentToInstallDir` helper. Both paths now extract the
payload into `tempFolder/inner-content/` first and walk it on
disk, instead of one path calling
`ZipFile.ExtractToDirectory(innerZip, installDir)` directly.
`CollectStagedFiles` simplified to a single on-disk walk;
`ExtractContentToInstallDir` simplified to a single code path.
B1 regression test `InnerZipFutureSymlinkRejectedTest` uses the
new fixture (262 bytes, generated by `build-symlink-fixture.ps1`).
Inner zip contains `legitfile.dll` (regular file) +
`evil-symlink.dll` (Unix mode `0o120755`, content
`/etc/passwd`). Today's .NET ZipFile ignores Unix mode bits and
materializes the entry as a regular file, but a future runtime
might honor them. Test asserts the future-proofing invariant:
install succeeds, `legitfile.dll` lands in installDir, and
installDir contains zero reparse points.
A5 CSharpExtension.cs -- wrap `File.Delete(libraryFile)` in an
`else` branch so the manifest path exclusively owns cleanup for
current-version installs. Direct `File.Delete` only runs as a
legacy-compat path for libraries installed by pre-PR builds that
have no manifest. Comments explain the split.
A6 CSharpExtension.cs -- add `s_reservedDeviceNames` HashSet
(OrdinalIgnoreCase) at the top of the class with all 22 Windows
reserved DOS device names (CON, NUL, AUX, PRN, COM1-COM9,
LPT1-LPT9, plus the COM0/LPT0 historical aliases).
`ValidateLibraryName` now rejects any libName whose stem
(`Path.GetFileNameWithoutExtension`) matches. New rows in
`InstallRejectsInvalidLibNameTest`: CON, nul, Aux, PRN, COM1,
LPT9, CON.dll, nul.manifest. Rejection is enforced on every OS
so behavior stays consistent for libraries moved between hosts.
A7 CSharpExtension.cs -- `DetermineAliasSource` now picks the
lexicographically-first `.dll` candidate
(`string.CompareOrdinal`) instead of the first one returned by
`Directory.GetFiles`. Stable across NTFS / ext4 / XFS and across
re-installs.
B6 CSharpExtension.cs + CSharpLibraryTests.cpp -- replace
content-sniff `IsZipFile` with extension-based `HasZipExtension`.
`InstallNonZipFileAsRawDllTest` (which asserted that a `.zip`
file with non-PK bytes was silently rewritten as `{libName}.dll`)
becomes `InstallZipExtensionWithBadContentFailsLoudlyTest` which
asserts `SQL_ERROR` + that the user's file is NOT silently
installed under a `.dll` rename. Pre-fix behavior would copy
`bad-package-ZIP.zip` to `bad-package.dll`; post-fix the user
gets a clear error.
B12 CSharpExtension.cs -- first check in `ValidateLibraryName` is
now `string.IsNullOrWhiteSpace(libName)`. New " " row in
`InstallRejectsInvalidLibNameTest`.
B13 CSharpExtension.cs -- remove `ValidateRelativePath` entirely.
After A4 collapsed both code paths through
`ZipFile.ExtractToDirectory` + on-disk walk it had zero callers;
the zip-slip defense it provided is now covered by (a)
`ZipFile.ExtractToDirectory`'s built-in zip-slip check and (b)
the on-disk walk being unable to see entries that escaped the
staged tree.
Test additions / hardenings:
B3 CSharpLibraryTests.cpp -- rename `DirectoryOverlapAllowedTest`
-> `NonConflictingFlatFilesCoexistTest`. Comment block tightened
to state "Both packages used here are flat (no nested
directories), so the test exercises the flat-file coexistence
case only -- nested-directory overlap is covered separately by
`ManifestListsNestedFilesTest` + `InnerZipFileConflictFailsTest`."
B4 CSharpLibraryTests.cpp -- harden `InstallZipWithManyFilesTest`
with a per-module existence loop (`Module1.dll` ... `Module50.dll`)
plus a comment block with the historical context (the original
`EXPECT_EQ(dllCount, 50)` passed legitimately because the old
install code created the alias as `{libName}` with no extension
-- test asserted what the code did, not what it should do).
Documentation:
B2 CSharpExtension.cs -- expand `IsReparsePoint` `<remarks>` block
with the concrete `sneaky.dll` -> `/etc/shadow` scenario and the
directory-level reparse-point variant. Notes the guard is
theoretical against today's .NET (which writes symlink-mode
entries as regular files) and links to
`InnerZipFutureSymlinkRejectedTest`.
B7 CSharpExtension.cs -- trim the `CleanupManifest` catch comment
to stop at the diagnostic-trail justification.
Style:
B5 / B9 / B10 -- blank line between adjacent independent constructs:
(1) file-loop / dir-loop split in `ExtractContentToInstallDir`,
(2) consecutive `if (e.find("MyLib.dll"))` /
`if (e.find("native.dll"))` flag-setters in
`ManifestListsNestedFilesTest`,
(3) consecutive `if (e.find("testpackageA"))` /
`if (e.find("testpackageB"))` flag-setters in
`AlterFromZipToZipTest`.
`}` followed by `continue;` guards or `return X;` function-tails
left alone -- those are idiomatic and adding blank lines there
would only add noise.
B11 CSharpLibraryTests.cpp -- one-line rationale comments at the
first write-site (`sentinelStream`) and the first read-site
cluster (`aliasStream` / `sourceStream`) explaining the pattern
of explicit `.close()` before `EXPECT_EQ` / `fs::exists` (gtest
macro failure paths can run arbitrary code; known-closed streams
are easier to debug). Same pattern is used at every other site
for consistency.
Test results: 112/112 unit tests pass on Windows release config.
…ptTest cannot revert to the pre-PR literal
yaelh asked to revert sicongliu's `"NonExistentLibrary"` rename for
git-blame continuity. Tried it locally; the test fails:
[ RUN ] CSharpExtensionApiTests.ExecuteInvalidLibraryNameScriptTest
Hello .NET Core CSharpExtension!
CSharpExecuteTests.cpp(125): error: Expected equality of these values:
result Which is: 0
(-1) Which is: -1
CSharpExecuteTests.cpp(127): error: Value of:
error.find("Unable to find user dll under") != string::npos
Actual: false Expected: true
The pre-PR literal `"Microsoft.SqlServer.CSharpExtensionTest"` is the
basename of `m_UserLibName`
(`"Microsoft.SqlServer.CSharpExtensionTest.dll"`), so the loader now
resolves it successfully to the real test DLL and the test no longer
observes the expected error. Sicongliu's rename was load-bearing, not
cosmetic.
Keep `"NonExistentLibrary"` and add a 4-line code comment at the call
site explaining why, so a future reader (or another reviewer) does not
attempt the same revert.
Test results: 112/112 unit tests pass on Windows release config.
|
Pushed two new commits addressing the third review pass:
Test results: 112/112 unit tests pass on Windows release config. Replies posted on every thread above. @yaelh @JustinMDotNet — ready for another look. |
| target_compile_options(dotnet-core-CSharp-extension-test PRIVATE --std=c++17) | ||
| target_compile_options(dotnet-core-CSharp-extension-test PRIVATE | ||
| "$<$<CXX_COMPILER_ID:MSVC>:/std:c++17>" | ||
| "$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:--std=c++17>" |
There was a problem hiding this comment.
Fixed in working tree -- changed --std=c++17 to -std=c++17 on line 28. Will be in the next commit. Thanks for the catch; agreed Clang would reject the long form even though GCC tolerates it as undocumented.
| // The library file is expected to be a zip. If it contains an inner zip, | ||
| // that zip is extracted to the install directory. Otherwise, all files | ||
| // are copied directly. |
There was a problem hiding this comment.
Fixed in working tree (will be in the next commit). The header comment now documents the dispatch-by-libraryName contract:
libraryNameending in.zip-> ZIP install (with single-inner-zip extraction support)libraryNameending in.dll-> raw DLL install (copy + one-entry manifest)- libraryName with neither extension -> falls back to
libraryFileextension (legacy compat)
Plus a note that a {libName}.manifest is always written so UninstallExternalLibrary can clean up exactly what was installed. Thanks for catching the staleness.
| if (name.StartsWith(libName + ".", s_pathComparison) || | ||
| name.Equals(aliasFileName, s_pathComparison)) | ||
| { | ||
| // A root-level file matches "{libName}.*" -- already discoverable. |
There was a problem hiding this comment.
Fixed in the working tree (next commit). Good catch; the underlying issue was actually broader than the libName-ends-in-".dll" case.
What the original code did: name.StartsWith(libName + ".", ...) || name.Equals(aliasFileName, ...) -- any root-level file beginning with "{libName}." suppressed alias creation.
Why that was wrong in general: the loader (DllUtils.CreateDllList) resolves a library by trying to map "{libName}.dll" as a PE binary. A sidecar like "foo.deps.json", "foo.runtimeconfig.json", or (in the case you flagged) "foo.dll.config" matches the StartsWith prefix but is not a loadable DLL -- so suppressing on it left the install with no root-level loader target.
Note on your literal scenario (libName="foo.dll"): the recently-added DispatchAsZip routes any libName ending in ".dll" straight to the raw-DLL install path, so DetermineAliasSource is never reached for that exact case in production. But the underlying logic was still latently wrong for the much more common libName="foo" case with sidecars at the root.
Fix: tightened suppression to require an exact match against aliasFileName (i.e. "{libName}.dll" -- the file the loader will actually map). Sidecars matching the "{libName}." prefix no longer count as "already discoverable". Also dropped the now-unused libName parameter from the method signature. The <remarks> block on DetermineAliasSource documents the rationale explicitly.
Regression test: AliasCreatedWhenOnlySidecarsAtRootTest in CSharpLibraryTests.cpp, using new fixture testpackageL-SIDECAR.zip (build script: build-sidecar-fixture.ps1). The fixture has foo.deps.json and foo.runtimeconfig.json at the root, and the actual DLL nested at lib/net8.0/foo.dll. Install with libName="foo" must produce a root-level "foo.dll" alias (cloned from the nested DLL). Pre-fix: the test fails because the sidecars suppressed alias creation. Post-fix: passes. All 113 tests now pass (was 112).
| SQLCHAR *libError = nullptr; | ||
| SQLINTEGER libErrorLength = 0; | ||
|
|
||
| SQLRETURN result = (*installFunc)( | ||
| SQLGUID(), | ||
| reinterpret_cast<SQLCHAR *>(const_cast<char *>(libName.c_str())), | ||
| static_cast<SQLINTEGER>(libName.length()), | ||
| reinterpret_cast<SQLCHAR *>(const_cast<char *>(libFilePath.c_str())), | ||
| static_cast<SQLINTEGER>(libFilePath.length()), | ||
| reinterpret_cast<SQLCHAR *>(const_cast<char *>(installDir.c_str())), | ||
| static_cast<SQLINTEGER>(installDir.length()), | ||
| &libError, | ||
| &libErrorLength); | ||
|
|
||
| errorMessage.clear(); | ||
| if (libError != nullptr && libErrorLength > 0) | ||
| { | ||
| errorMessage.assign(reinterpret_cast<char *>(libError), | ||
| static_cast<size_t>(libErrorLength)); | ||
| } |
There was a problem hiding this comment.
Fixed in the working tree (next commit). Added a FreeLibError(SQLCHAR *) helper in CSharpLibraryTests.cpp and call it on every libError pointer returned from the three test helpers (CallInstall, CallUninstall, CallInstallCaptureError) before they return -- so SQL_ERROR paths no longer accumulate unmanaged allocations across the gtest run.
Allocator pairing. All tests in this file call the managed Install/UninstallExternalLibrary exports in Microsoft.SqlServer.CSharpExtension.dll. The managed SetLibraryError (CSharpExtension.cs) allocates via Marshal.AllocHGlobal, which on Windows is backed by LocalAlloc -- so the matching deallocator is LocalFree, which is what FreeLibError calls. Production ExtHost releases the buffer the same way.
(Note: the native pre-flight SetLibraryError in nativecsharpextension.cpp uses malloc(), not LocalAlloc. On Windows, LocalFree on a malloc'd pointer is undefined behavior. But those tests in this file never exercise that pre-flight path -- every libError they ever see is AllocHGlobal-backed. The new FreeLibError comment documents this explicitly.)
All 113 tests still pass.
yaelh
left a comment
There was a problem hiding this comment.
Signing off since the remaining comments are small and trivial to implement. also since I'll be OOF I don't want to block the PR
|
Please run PVS once before merging this PR. I have shared the instructions with Justin. |
|
Acknowledged @monamaki — coordinating with @JustinMDotNet to run PVS against the dotnet-core-CSharp extension. We'll post the results (and address any findings) before requesting re-review. Thanks for getting the instructions over to him. |
Production fixes (end-to-end testing against SQL Server 2025 RTM-GDR): * CSharpExtension.cs AcquireInstallLock: place install.lock under Path.Combine(installDir, "install.lock") instead of one level up so concurrent installs into different <dbid>/<langid> slots don't serialize against one another. * CSharpExtension.cs new DispatchAsZip(libName, libFilePath): install dispatch is now driven by the registered library name's extension (.zip -> ZIP, .dll -> raw DLL, otherwise fall back to libFilePath's extension). ExtHost passes a generated temp filename with no semantic suffix in production, so the previous extension-sniff on libFilePath was meaningless there; the libFilePath fallback preserves the legacy contract for test fixtures registered under a bare library name. * CSharpOutputDataSet.cs + utils/Sql.cs DotNetNVarChar plumbing: add the DotNetNVarChar row to Sql.DataTypeSize and a DotNetNVarChar case alongside DotNetWChar in ExtractColumn / GetStrLenNullMap. Previously fell through to default and threw KeyNotFoundException in DataTypeSize before the column reached the dispatch switch. * CSharpOutputDataSet.cs DotNetWChar / DotNetNVarChar Size unit: report Size in BYTES, matching the unit emitted by GetStrLenNullMap (Encoding.Unicode.GetByteCount). The previous code reported a character count, which combined with a byte-count length map caused SPEES to log "Reading one row failed for column N row M. The length information is incorrect." and reject the rowset whenever a string column contained non-ASCII data. Reviewer items (PR microsoft#85 May 13 review): * test/src/native/CMakeLists.txt: non-MSVC -std=c++17 (one dash) instead of --std=c++17 to match the documented spelling and the rest of the build tree. * include/nativecsharpextension.h: rewrite InstallExternalLibrary doc comment to spell out the new libName-based dispatch contract and the {libName}.manifest file written for every install (ZIP or raw DLL). * CSharpExtension.cs DetermineAliasSource: alias-suppression now requires an EXACT match against "{libName}.dll" at the install root. The previous prefix check ("{libName}.") suppressed alias creation for ZIPs that planted only sidecars at the root (foo.deps.json etc.) with the real binary nested under lib/net8.0/, leaving the install un-loadable. Drops the now-unused libName parameter from the signature. Pinned by AliasCreatedWhenOnlySidecarsAtRootTest + testpackageL-SIDECAR.zip fixture (build-sidecar-fixture.ps1). * test/src/native/CSharpLibraryTests.cpp new FreeLibError(SQLCHAR *) helper using LocalFree (matches the production Marshal.AllocHGlobal / LocalAlloc allocator that ExtHost uses on the consumer side). Wired into CallInstall, CallUninstall, and CallInstallCaptureError so the test harness no longer leaks the libError buffer on every failing-install assertion. Tests: 113/113 unit tests pass on Windows release config (one new TEST_F: AliasCreatedWhenOnlySidecarsAtRootTest).
Summary
Extends
InstallExternalLibrary/UninstallExternalLibraryin the .NET Core CSharp Language Extension to support ZIP archives with arbitrary file trees (e.g. packages with nested folders), not just flat DLL files. Also makesInstallExternalLibraryidempotent soALTER EXTERNAL LIBRARYworks correctly.What changed vs. sicongliu's base branch
1. Manifest-based uninstall
UninstallExternalLibraryonly receivesLibraryName+LibraryInstallDirectoryΓÇö noLibraryFile, so we cannot re-read ZIP entries fromsys.external_librariesat drop time. To work around this,InstallExternalLibrarynow writes a<libName>.manifestfile listing the relative paths of every extracted file (or, for raw-DLL installs, the single<libName>.dllentry).UninstallExternalLibraryreads that manifest and deletes exactly those files ΓÇö no more, no less.The previous uninstall implementation deleted everything in the shared install directory, which would wipe out unrelated libraries' files.
2. File-level conflict detection on install
Before extracting a ZIP, every entry is checked against the install directory. If a file of the same name already exists (from another library), install fails with a clear error:
Directory (folder) overlaps are allowed ΓÇö multiple libraries can share a parent folder; they just can't overwrite each other's files.
3. Empty-directory cleanup on uninstall
After a manifest-driven delete, parent directories of removed files are walked deepest-first (sorted by separator count) and removed only if empty. Shared parent folders survive as long as any other library still has content in them.
4.
ALTER EXTERNAL LIBRARYsupport (transactional re-install)SQL Server may call
InstallExternalLibraryagain for the same library name duringALTER EXTERNAL LIBRARYwithout first callingUninstallExternalLibrary. The install now:If the new ZIP is corrupt or conflicts with another library, the old version is left intact ΓÇö the install is atomic from the caller's perspective.
5. Defense in depth
ZipFile.ExtractToDirectoryrejects path-traversal entries at extraction time. The single-path collapse in review pass 3 (see Update below) means the install code never sees an entry that escaped the staged tree, so there is no second-level vector to defend.CopyDirectoryskips entries with theReparsePointattribute at every recursion level ΓÇö both file and directory symlinks. Today's .NET writes symlink-mode ZIP entries as regular files on every platform; this guard is future-proofing for a runtime that materializes them as real symlinks. Pinned byInnerZipFutureSymlinkRejectedTest.ValidateLibraryNamerejects null/empty/whitespace-only names, names containing../ path separators / null characters, absolute paths, extension-only names like.dll, and Windows reserved DOS device names (CON, NUL, AUX, PRN, COM1ΓÇôCOM9, LPT1ΓÇôLPT9 ΓÇö bare or suffixed). Reserved-name rejection is enforced on every OS so libraries moved between hosts behave consistently.SetLibraryErrorin the native code allocates the error buffer withmalloc(matching the managedMarshal.AllocHGlobalcontract that ExtHost expects on the other side), so ownership transfers cleanly to the host. Pre-fix returned ac_str()pointer into a freedstd::stringΓÇö undefined behavior on the host side.Ordinalon Linux andOrdinalIgnoreCaseon Windows, so/install/Liband/install/libare correctly treated as distinct paths on Linux.Error-handling matrix
<libName>.dll; manifest written so uninstall and ALTER work uniformly<libName>.dllplanted by another library or external toolingbFailIfExists=TRUEcontract).zipextension whose bytes are not a valid ZIP<libName>.dll<libName>.dllis deleted directly (legacy-compat path for libraries installed by pre-PR builds)ALTER EXTERNAL LIBRARYwith valid new contentALTER EXTERNAL LIBRARYwith corrupt new content../path separator/null character, is an absolute path, is extension-only (.dll), or is a Windows reserved DOS device name (CON/NUL/AUX/PRN/COMn/LPTn ΓÇö bare or suffixed)IsReparsePointguard skips the entry; legitimate files still installTests
28 new
TEST_Fcases inCSharpLibraryTests.cppcovering:<libName>.dllalias naming and removal on uninstallALTER-style re-install (ZIPΓåöZIP, ZIPΓåönon-ZIP, non-ZIPΓåönon-ZIP, ALTER to empty-dirs ZIP preserving v1)libraryError<libName>.dllexists.dll-suffixed library name.zip-extension file with non-ZIP bytes fails loudly (does not silently rewrite the user's file)Update (review pass 2)
Atomicity contract clarified: Install is NOT atomic at the per-file level. A crash between
CleanupManifestand theCopyDirectoryloop can leave the install directory inconsistent. End-to-end recovery is provided by SQL Server's library management architecture: the catalog is the source of truth, and the next session re-installs from the catalog. The in-extension code is staging-validated (corrupt ZIPs cannot start a destructive cleanup) but is not crash-safe.Raw-DLL installs now write a manifest. A one-entry
{libName}.manifestlisting{libName}.dllis written for raw-DLL installs as well as ZIPs. This:CopyFileW(..., bFailIfExists=TRUE)contract: a foreign{libName}.dllplanted by another library or external tooling is no longer silently overwritten ΓÇö install fails.Other v3.8.0 hardening: native
SetLibraryErrornull-check, ZIP file opens withFileShare.Read,CopyDirectoryskipsReparsePointentries (Linux symlink defense), alias suppression now correctly counts only root-level matches (DllUtils.CreateDllListis non-recursive), various comments / doc improvements per inline review.Update (review pass 3)
Addresses 4 inline comments from JustinMDotNet and 13 from yaelh. Tip:
8ef1573.Native / managed correctness fixes:
nativecsharpextension.cppSetLibraryError: replacednew std::string(errorString)+c_str()withmalloc(len + 1)+memcpy. Buffer ownership transfers cleanly to ExtHost; OOM path returns the no-error state instead of crashing.CSharpExtension.csAcquireInstallLock: narrowed the catch viawhen (IsSharingViolation(ex))filter (HResultERROR_SHARING_VIOLATION (32)/ERROR_LOCK_VIOLATION (33)).DirectoryNotFoundException,PathTooLongException,UnauthorizedAccessException, etc. now propagate fast instead of being swallowed.DllUtils.cs: replacedDirectory.GetFiles(searchPath, "{name}.*")+.Where(...EndsWith(".dll"))withEnumerateFiles+ explicitEquals(".dll", OrdinalIgnoreCase)on extension andEquals(userLibName, OrdinalIgnoreCase)on stem. Eliminates the*.dllmatchingfoo.dllxandFoo.*matching short-name 8.3 alias quirks.CSharpExtension.csinstall path collapsed to a single code path: both inner-zip and outer-zip cases now extract intotempFolder/inner-content/first and walk the result on disk viaIsReparsePoint-guardedCopyDirectory. Removed the direct-extract-to-installDir shortcut so a future runtime that materializes Unix symlink-mode entries can't bypass the reparse-point guard.ValidateRelativePathremoved (zero callers after the collapse ΓÇö zip-slip defense is now provided byZipFile.ExtractToDirectoryplus the on-disk walk being unable to see entries that escaped the staged tree).InnerZipFutureSymlinkRejectedTest+ fixturetestpackageK-SYMLINK.zip(262 bytes, generated bybuild-symlink-fixture.ps1). Inner zip containslegitfile.dll+evil-symlink.dll(Unix mode0o120755, content/etc/passwd). Asserts: install succeeds,legitfile.dlllands in installDir, and installDir contains zero reparse points.UninstallExternalLibrary: wrappedFile.Delete(libraryFile)in anelsebranch so the manifest path exclusively owns cleanup for current-version installs. The direct delete only runs as a legacy-compat path for libraries installed by pre-PR builds with no manifest.DetermineAliasSource: lexicographically-first.dllcandidate viastring.CompareOrdinal(stable across NTFS / ext4 / XFS and re-installs).Validation / behavior tightening:
ValidateLibraryName: now rejects whitespace-only names (IsNullOrWhiteSpace) and Windows reserved DOS device names (full set: CON, NUL, AUX, PRN, COM0ΓÇôCOM9, LPT0ΓÇôLPT9). Stem is checked viaPath.GetFileNameWithoutExtension, soCON,CON.dll, andnul.manifestare all rejected. Enforced on every OS for consistency. New rows inInstallRejectsInvalidLibNameTest.IsZipFilecontent-sniff replaced by extension-basedHasZipExtension. A.zipfile whose bytes are not a valid archive now returnsSQL_ERRORinstead of being silently rewritten as<libName>.dllΓÇö the user's registered filename is opaque to the install path. The corresponding test was renamed toInstallZipExtensionWithBadContentFailsLoudlyTestand inverted to assert the loud-failure behavior.Test hardenings:
InstallZipWithManyFilesTest: per-module existence loop (Module1.dll…Module50.dll) plus a comment block with the full historical context (the originalEXPECT_EQ(dllCount, 50)agreed with the buggy install code that created the alias as{libName}with no extension — test asserted what the code did, not what it should do; fixed in commit38c553d).DirectoryOverlapAllowedTestrenamed toNonConflictingFlatFilesCoexistTestwith a tighter comment block clarifying that nested-directory overlap is covered separately.Docs / style:
IsReparsePoint<remarks>now includes the concretesneaky.dll → /etc/shadowworked example covering both file and directory reparse-point cases.CleanupManifestcatch comment to stop at the diagnostic-trail justification.}sweep applied where the pattern was genuinely two adjacent independent constructs (file/dir loops, consecutive flag-setterifs); guard-then-action}followed bycontinue;/return X;left alone..close()sites in test code so the pattern is self-documenting.Test results: 112/112 unit tests pass on Windows release config.
Update (review pass 4)
Addresses 4 inline comments from JustinMDotNet (CMake std flag, header doc contract, alias suppression for sidecar-only roots, native error-buffer ownership in tests) plus 4 production-found bugs from end-to-end testing against SQL Server 2025 RTM-GDR.
Production fixes (end-to-end testing):
CSharpExtension.csAcquireInstallLock: lock file is now created atPath.Combine(installDir, "install.lock")instead of one level up. The previous path put the lock outside the per-<dbid>/<langid>install directory, so concurrent installs into different DB/language slots serialized against each other unnecessarily and (worse) a single install could race against itself when the parent directory was on a separately-permissioned mount.CSharpExtension.csnewDispatchAsZip(libName, libFilePath)helper: install dispatch is now driven by the registered library name's extension, not the staged temp file's extension. SQL Server's ExtHost passes a generated temp file with no semantic suffix, so the previous content-/extension-sniff onlibFilePathwas meaningless in production. Order of resolution islibNameends in.zip→ ZIP,libNameends in.dll→ raw DLL, otherwise fall back tolibFilePath's extension (preserves the legacy contract for test fixtures that register libraries by bare name and pointlibraryFileat a*.zip/*.dllfixture).CSharpOutputDataSet.cs+Sql.csDotNetNVarCharplumbing: theSqlDataType.DotNetNVarCharenum value (thestringrow inSql.DataTypeMap) had no entry inSql.DataTypeSizeand no case inCSharpOutputDataSet.ExtractColumn/GetStrLenNullMap. Calls fell through todefaultand threwKeyNotFoundExceptioninDataTypeSizebefore the column ever reached the dispatch switch. Fixed by adding theMinUtf16CharSizerow toDataTypeSizeand aDotNetNVarCharcase alongsideDotNetWCharin both switches (they're SQL_C_WCHAR-shaped at the ODBC layer and share an implementation).CSharpOutputDataSet.csDotNetWChar/DotNetNVarCharSizeunit:Sizeis now reported in bytes, matching the unit emitted byGetStrLenNullMap(Encoding.Unicode.GetByteCount). The previous code divided by a UTF-16 code-unit width and reported a character count, so SPEES logged"Reading one row failed for column N row M. The length information is incorrect."and rejected the rowset whenever a string column contained non-ASCII data.Reviewer fixes:
test/src/native/CMakeLists.txt: non-MSVCtarget_compile_optionsflag changed from--std=c++17to-std=c++17(one dash). GCC and Clang accept both spellings, but the single-dash form is the documented one and matches the rest of the build tree.include/nativecsharpextension.h:InstallExternalLibrarydoc comment rewritten to spell out the new dispatch contract (libName-based, not libFile-content-based) and to document that a{libName}.manifestis written for every install (ZIP or raw DLL). The previous comment described only the legacy "raw DLL or ZIP detected from the file" behavior.CSharpExtension.csDetermineAliasSource: alias-suppression now requires an exact match againstaliasFileName("{libName}.dll") at the install root. The previous check accepted any root-level entry whose name started with"{libName}.", so a ZIP that planted only sidecars at the root (e.g.foo.deps.json,foo.runtimeconfig.json) and kept the real binary nested underlib/net8.0/foo.dllwas treated as iffoo.dllwere already loadable — alias creation was suppressed and the install was un-loadable. The unusedlibNameparameter was dropped from the signature in the same change. New regression testAliasCreatedWhenOnlySidecarsAtRootTest+ fixturetestpackageL-SIDECAR.zip(491 bytes, generated bybuild-sidecar-fixture.ps1) pins the new behavior.test/src/native/CSharpLibraryTests.cppnewFreeLibError(SQLCHAR *)helper usingLocalFree(matches the productionMarshal.AllocHGlobal/LocalAllocallocator that ExtHost expects on the consumer side). Wired intoCallInstall,CallUninstall, andCallInstallCaptureErrorso the test harness no longer leaks thelibErrorbuffer on every failing-install assertion. Pre-fix the harness allocated, ignored, and never freed — a 113-test run accumulated kilobytes of orphanlibErrorstrings on the heap.Test results: 113/113 unit tests pass on Windows release config (one new
TEST_F:AliasCreatedWhenOnlySidecarsAtRootTest).